feat(plugin): add graphql-limit-count plugin#13372
Conversation
Add a new plugin that limits GraphQL request rates based on query AST depth using a fixed window algorithm. The plugin reuses the limit-count infrastructure for counter management and supports local, Redis, and Redis cluster policies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add Apache license header to t/plugin/graphql-limit-count.t - Add plugin and shared dict entries to conf/config.yaml.example - Rewrite EN/ZH docs to follow open-source format with Tabs and realistic curl examples showing expected response headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add canonical link in <head> section - Use correct attribute table format (Name/Type/Required/Default/Valid values/Description) - Replace Tabs component with plain curl examples - Follow the same structure as traffic-label and exit-transformer docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new graphql-limit-count traffic control plugin that reuses limit-count infrastructure to charge requests by parsed GraphQL query depth.
Changes:
- Adds the new plugin implementation and registers it in default plugin/admin configuration.
- Adds NGINX shared dict configuration for the plugin.
- Adds tests, documentation, and sidebar entries for English and Chinese docs.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
apisix/plugins/graphql-limit-count.lua |
Implements GraphQL parsing, depth calculation, and limit-count integration. |
apisix/cli/config.lua |
Adds plugin and shared dict defaults. |
apisix/cli/ngx_tpl.lua |
Adds conditional shared dict declarations. |
conf/config.yaml.example |
Registers plugin and shared dicts in example config. |
t/plugin/graphql-limit-count.t |
Adds plugin functional tests. |
t/admin/plugins.t |
Adds plugin to admin plugin list expectations. |
docs/en/latest/plugins/graphql-limit-count.md |
Adds English plugin documentation. |
docs/zh/latest/plugins/graphql-limit-count.md |
Adds Chinese plugin documentation. |
docs/en/latest/config.json |
Adds English sidebar entry. |
docs/zh/latest/config.json |
Adds Chinese sidebar entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix max_query_depth: use proper max nesting depth traversal instead of counting total selection set nodes - fix content-type matching: use has_prefix instead of exact equality to handle types with charset parameters (e.g. application/json; charset=utf-8) - remove incorrect 'query' keyword check for application/graphql requests: shorthand queries and mutations are valid without 'query' keyword - fix max_size: read graphql.max_size from local config instead of passing nil (no limit) to get_body - fix typo: cant't -> can't in error message - return specific error message from check_graphql_request instead of generic 'no query' - fix ngx_tpl.lua: declare plugin-limit-count-redis-cluster-slot-lock dict when graphql-limit-count is enabled without limit-count - fix tests: add no_shuffle(), redis flush in init_worker, rejection path test, application/graphql content-type success test, and update response_body assertions to match specific error messages
The shared dicts (plugin-graphql-limit-count, plugin-graphql-limit-count-reset-header, and plugin-limit-count-redis-cluster-slot-lock) are already generated by ngx_tpl.lua when graphql-limit-count is enabled. Declaring them again in http_config caused nginx to fail with 'already defined' error.
The test framework t/APISIX.pm maintains a hardcoded list of shared dicts for the test nginx environment. New plugin dicts must be added here, otherwise ngx.shared[dict] returns nil at request time. Add plugin-graphql-limit-count and plugin-graphql-limit-count-reset-header to the list, following the same pattern as plugin-ai-rate-limiting.
Separate test blocks cause nginx to restart between them, resetting the shared dict counter. Merge the two rate limit checks (200 + 503) into a single pipelined_requests block so both requests share the same nginx instance and counter state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Example: query {
user {
...Deep
}
}
fragment Deep on User {
posts {
comments {
author {
id
}
}
}
}The plugin should first build a fragment definition map, then calculate depth from the operation selection set. When it encounters a |
…s in depth calculation Fragment spreads (named fragments) and inline fragments were not expanded during depth traversal, allowing clients to hide deeply-nested queries behind fragments and bypass the depth-based rate limit. Fix: - fragmentSpread nodes are expanded in place by recursing into the referenced fragment's selections (via a fragment map built from the document definitions). - inlineFragment nodes are treated as transparent wrappers, recursing directly into their selections without adding an extra depth level. - A visited set guards against fragment definition cycles to prevent infinite recursion. - Only executable operation definitions are traversed for depth; fragment definitions are excluded from the root traversal to avoid standalone depth being counted independently. Tests added for fragment spread depth, inline fragment depth, and fragment cycle protection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e tests
- Fix fragment map key bug: def.name is a Name AST node {kind='name', value='...'}, not a plain string.
Use def.name.value as map key and node.name.value for lookup.
- Add TEST 11: verify application/json; charset=utf-8 is accepted
- Add response_headers_like assertion to fragment cycle test
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split TEST 20 into a setup block (TEST 20) and hit block (TEST 21) so the inline fragment depth assertion does not rely on accumulated counter state from the preceding fragment-spread test. Both tests now independently verify that depth-4 queries consume 4 quota units from a fresh count-20 window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mber The max_size config value is read via try_read_attr and may arrive as a string if the YAML value is quoted or sourced from an env substitution. Use tonumber() to ensure a numeric value is passed to get_body, falling back to the default when the config value is not a valid number. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e limit Add TEST 23 (route setup with max_size: 100) and TEST 24 (oversized request body returns 400) to cover the graphql.max_size enforcement path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nimum 1 - Reject zero/negative graphql.max_size from config to avoid accepting no requests or behaving unexpectedly; fall back to default 1MiB. - Clamp computed depth to minimum 1 before rate limiting so that a degenerate operation with no selections never bypasses rate limiting with cost=0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
lj-releng reports an error for Lua globals accessed without local bindings. Add local tonumber = tonumber with the other local aliases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… test Add response_body_like assertion to TEST 24 to confirm the 400 is specifically from the body size limit, not an unrelated validation error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace raw body content in error logs with body size in bytes to prevent leaking sensitive user data (PII or secrets) from GraphQL request payloads into log files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…_size The body size limit triggers core.log.error which conflicts with the default no_error_log [error] added by the preprocessor. Add error_log to expect the size-exceeded log and replace the default check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add TEST 25/26 to verify that fragment spreads referencing other fragments (A->B) are fully expanded when computing query depth. This covers the scenario where a shallow root query uses a fragment that itself uses another fragment to reach deeply nested fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
What does this PR do?
Add a new
graphql-limit-countplugin that limits GraphQL request rates based on the depth of the query Abstract Syntax Tree (AST) within a given time window.Why is this change needed?
Standard rate limiting counts every request equally, regardless of its computational cost. This is insufficient for GraphQL endpoints where the cost of execution scales with query complexity. A single deeply nested query can be far more expensive than dozens of simple ones:
Without depth-aware limiting, a client could exhaust server resources with a single request while staying well within a request-count limit.
The
graphql-limit-countplugin solves this by using the GraphQL query AST depth as the cost for each request, powered by the existinglimit-countinfrastructure. This lets operators configure a single depth budget per time window and have it consumed proportionally by query complexity.How does it work?
POSTrequests withapplication/json(containing aqueryfield) orapplication/graphqlcontent type.graphql.parse()to build the AST and recursively counts the maximumselectionsnesting depth....FragName) and inline fragments (... on Type { ... }) are fully expanded before depth is computed, with cycle detection to prevent infinite loops.limit_count.rate_limit(conf, ctx, plugin_name, depth), passing the depth as the cost.count, the request is rejected with the configuredrejected_code.Example
Configure a route to allow a cumulative query depth of 10 per minute per client IP:
A depth-4 query consumes 4 out of 10. After the budget is exhausted:
Fragment spreads are expanded before computing depth, so the following query has the same depth (5) as the equivalent inline query:
Chained fragments (A spreading B spreading C) are also handled correctly.
Changes
apisix/plugins/graphql-limit-count.luat/plugin/graphql-limit-count.tapisix/cli/config.luaplugin-graphql-limit-countandplugin-graphql-limit-count-reset-headershared dict defaultsapisix/cli/ngx_tpl.luaconf/config.yaml.exampledocs/en/latest/plugins/graphql-limit-count.mddocs/zh/latest/plugins/graphql-limit-count.mddocs/en/latest/config.jsondocs/zh/latest/config.jsont/admin/plugins.t